Repository navigation
Conversation
Files Cycle v5 of PRD-573 as its own PRD (worker mode, Wasm threads), records the owner's 2026-10-26 worker HUD decision and clears the PRD-573 blocker.
Keeps N busy-loop processes alive for the whole page run and stamps the count on the report, so a loaded-host baseline is reproducible.
…baseline (PRD-752) - check-core-boundary type-checks packages/core/src against the webworker lib and fails on any value read of a global a worker lacks outside platform.ts (red on 19 reads in 5 files, green after the move). - platform.ts gains hostWindow/hostDocument/hostResizeObserver; audio, game, hot, renderer and viewport use them. The renderer now throws TN_RENDERER_NO_CANVAS instead of a ReferenceError with no document. - profile:wasm-page reports main-thread busy percent. - PRD-752 phase 1 baseline and v5 KPIs recorded; owner scope decision.
…752) Both ends stay in one realm but every frame arrives a task later, the hop a game in a worker adds. Installed as the host globals by whichever end connects first, so the game and the HUD do not change.
…named WebGPU failure (PRD-752) A scratch starter booted in a worker on an OffscreenCanvas on the Wasm engine. These are the engine-layer fixes that spike needed: - core: defineGame takes a platform source a worker host installs (__tnGamePlatform) when the portable entry names none. - core: TN_NO_RENDERER names the WebGPU failure instead of swallowing it. - create-threenative: worker bundles get the engine resolver and ES output. - runtime-native: the web module is built for web and worker environments. - three-native: the renderer tolerates an OffscreenCanvas (no setAttribute). - PRD: the spike result and the phase 3 design.
… an OffscreenCanvas (PRD-752) serveWorkerGame (worker) and startWorkerGame (page) speak a small message contract: a ready handshake before the canvas transfer, the platform source the game boots on, page-relative URLs, forwarded keyboard/pointer/wheel input in canvas coordinates, the UI bridge on its own port, and the playtest bridge mirrored onto the page as async calls. 9 linked-pair tests.
…d (PRD-752) A worker has no Web Audio API. The worker gets a stand-in AudioContext whose nodes are handles; calls and property writes cross a MessagePort in one batch per task and run on the page's real graph, so three's audio classes and AudioBus work unchanged. AudioBus finds its gesture target in a worker, canvas gestures reach it as they would by bubbling, and a page gesture resumes the page's context. 7 remote-audio tests, 2 worker-host tests, 1 platform test.
… module loads (PRD-752) The starter's game.ts calls game.ui.onIntent at top level, so in a worker the game end connected before the page's port arrived and stayed on the in-process broker: a blank HUD. @threenative/core/worker now installs the game end's host globals when it loads in a worker, before the game module evaluates, and holds frames each way until init attaches the port. 3 tests; headed GPU probe shows the full HUD on the worker page.
src/worker.ts serves the unchanged game.ts; src/worker-page.ts keeps the canvas, input and the same GameUi on the page. index.html stays the default, so one build carries both arms of the busy-page A/B. Headed GPU probe: same frame and HUD as index.html.
A worker page's requestAnimationFrame times the page, not the game. The probe now reads frame intervals in the worker that holds the game's platform source, else on the page, and names that thread in the result (gameThread). Main-thread busy stays the page's CPU profile. 1 test.
…D-752) An OffscreenCanvas cannot lock the pointer. In a worker its requestPointerLock and exitPointerLock ask the page, which locks its own canvas within the click's activation window and forwards each pointerlockchange with the page's verdict as captured; a refused lock is reported as captured false. InputMap takes that flag where no document exists, unchanged on a page. 2 tests.
A worker's requestAnimationFrame does not follow what the game presents, and on a loaded page rAF ticks at 60 Hz while the game presents far fewer frames. The probe now also times the game's getCurrentTexture calls (the gpuCalls divisor) on the game's own thread, in both arms, as presented; it shims tsx's __name in the game worker, and fails closed with a timeout. 1 test.
…sions (PRD-752) The shooter kit gets the starter's worker page: src/worker.ts serves the unchanged game.ts, src/worker-page.ts keeps the canvas, input and GameUi. Headed probe on the RTX 2080: town, soldiers, rifle and the live HUD with minimap and clock draw from the worker. The PRD logs gaps 7 to 9, the presented-frame timing, the loaded-host rule and the measured busy share (starter 0.9 to 1.9% on worker.html against 45.7 to 86.9% on the page).
…nner (PRD-752) With a runner announced, the playtest plugin holds the boot until the runner calls the bridge, so a mirror sent after start never reached the page and the harness reported TN_PLAYTEST_BRIDGE_MISSING. The worker now posts each mirror the moment the game installs the global. 1 test.
index.html now runs the game in a web worker; main.html is the named override that runs it on the page thread. The worker canvas forwards setAttribute to the page canvas, so the harness reads the renderer's data-engine for capture provenance (was TN_PLAYTEST_CAPTURE_PROVENANCE_MISSING). 1 test.
Mesh::raycast tested every triangle of the mesh. The shooter's merged town meshes hold about 430k triangles, so a few soldier line-of-sight rays cost about 90 ms in one update, which was the stutter left on the worker page. The tree is built on first use, rebuilt when positions, index or drawRange change, and skipped for morphed meshes and meshes that pose their vertices (SkinnedMesh). Candidates run the unchanged hit code in triangle order, so results stay identical: 2,048 oracle rays, 0 differ. 1,000 rays on a 20k mesh: 8,471 ms -> 6.0 ms; a focused ray tests 4 of 20,000 triangles.
… and profiler (PRD-752) Worker: startup errors reject ready, stop removes the canvas and releases pointer lock and capture, pointerlockerror, 0x0 clamp, HUD wheel stays on the HUD, motion coalesced per page frame, one HUD snapshot in flight, gamepads forwarded, a lazy playtest mirror with _clockEpoch. Audio: the clock freezes while suspended, fluent AudioParam calls reuse their handle, ended one-shots release theirs, numeric param defaults, control ops settle from the page. Profiler: probes run in the game realm. Each item has a red-green spec.
… A/Bs (PRD-752) The default flags unthrottle the page's requestAnimationFrame, not a worker's OffscreenCanvas frames, so a worker arm read 37 ms frames against 10 ms on the page. With vsync on, the shooter worker ran 16.7 ms frames against 33.3 ms on the page thread. Also types the memory probe's realm (Page | Worker union).
…PRD-752) Under a private Xvfb the runner adds --disable-frame-rate-limit so a page game's frames escape the compositor's cadence. The same flag throttles a worker's OffscreenCanvas frames (shooter: 36 ms presented with it, 16.7 ms without). The runner now sees a worker game once the page opens and relaunches once with that thread's flags; the report says gameThread: worker.
…quests (PRD-752) A wall-clock advance answers a one-tick request with the ticks the host's pump ran: two per frame at 30 fps on a 60 Hz step. advanceFixedStep counted requests, so a 169-tick key hold ran about 338 ticks and the starter's player overshot into the gap on the worker page. The bridge client now returns the reported count and the loop counts it; a fixed-step bridge still runs exactly one tick per request. Starter play passes on / (worker) and /main.html.
…ock hold decisions
|
Visual judge: NEUTRAL. The worker page ( A fresh judge compared 6 frames (two runs per arm, RTX 2080, headed WebGPU). Exposure, tone, shadows, anti-aliasing, resolution and the HUD match. The only differences are gameplay state: the starter's character is 1 to 2 position units apart at the capture tick.
All sheets: branch |
… display matches the page thread
… splittable engine work)
…pool waits on PRD-573 v3.1
|
From the PRD-752 lane (#576), for the #574 coordinator. #576 is ready for you to merge into #574. It stays a draft, and nothing went to develop. Head SHA: 6639da5 (branch prd-573-v5-worker-mode). Merge check: Gates run, with real results:
One risk for your CI, decision yours: the shooter's performance.playtest.json fails on / under the runner's private Xvfb (minFps 15-20, cost p95 17.7 ms). Xvfb holds a worker's OffscreenCanvas frames to about 25/s at 1920x1080, and no scheduler or Chrome flag lifts it (probe on #576). On the real display the worker matches the page thread (update p95 8.0 vs 7.3 ms, render p95 10.8 vs 10.4 ms). There, BOTH arms fail the kit's own budgets the same way: 796 draw calls > 320, frame p95 16.8-17.2 ms > 16.7. Under Xvfb both arms skip most draws (median draw calls per sample 0), which is why /main.html looked green. Options: (1) run that scenario with --url .../main.html, (2) run it on a real-display lane, (3) a harness-wide virtual-display rule like perf's TN_PERF_VIRTUAL_DISPLAY. I did not soften the evaluator: packages/playtest/AGENTS.md forbids it. Files to keep (all from #576): packages/core/src/{worker,worker-audio,platform,ui-bridge,game,input,audio,hot,renderer,viewport}.ts; templates/{starter,shooter}/{index.html,main.html,src/worker.ts,src/worker-page.ts,vite.config.ts,AGENTS.md}; packages/playtest/src/runner/{runner,steps,bridgeClient,browser,shared,androidRunner}.ts; the BVH raycast in packages/runtime-native/src/engine/scene/raycaster.*; scripts/profile-wasm-page.ts (--vsync). Waste found while profiling, for the "waste first" order (not done here): tn_context::share(std::string, ...) is 8.7% of Midway's main thread, and projection::contentClassOf is 5.1% and looks cacheable. Phase 4 (Wasm threads) stays open on PRD-752: it waits on PRD-573 v3.1's job pool. Measured: a -pthread build costs 1-5% on one thread; the Midway job-pool ceiling is about 19% at 4 threads. |
…5-3%); earlier A/B void
|
Correction to the ready report above: the threaded-build cost is about 1.5-3% on one thread (Midway, 5 runs per arm, both arms from this PR's sources: median render p50 16.65 vs 17.11 ms). The earlier 1-5% A/B is void: its single-thread arm came from a stale build/web pair built from another tree. Head is now c4fe5ef. |
|
Folded into #574 (branch pr563-followup, merge e2462de, head c4fe5ef). On the merged tree: repo typecheck 0; scaffold spec 74/74 (starter + shooter hashes re-pinned); worker, ui, animation, HUD-audit and quality-rules specs 89/89; web Wasm rebuilt from the merged C++ (stamp check passes). Worker shooter, headed WebGPU: hands-authored-rate and breakable-shatters exit 0; HUD sampled every 25 ms from load showed 0 of 320 samples under the loading cover. Your performance.playtest.json Xvfb note is logged on #574 and gets rerun after the auto-batching lane lands. Closing this PR as folded into #574. |
…erial merge by default (PRD-748) Coordinator takeover: the lane stalled after its two commits. Conflict in the shooter's Play.ts: kept the sight index from HEAD and dropped collapseTown, which AutoBatch replaces. Shooter hash re-pinned. core/src/worker.ts snapshotInFlight -> snapshotPending: the visual-concerns scan in constraints.spec matched 'light' inside 'InFlight' (red since the #576 merge, not this lane). All core specs 276 files, 4309 tests pass; typecheck 0; scaffold 74/74.





What is this PR about?
Web games will run their game code and engine in a background worker, not on the page's main thread. The page keeps only the HUD, input and audio. The plan, the loaded-host baseline and the first core change have landed.
Why does it matter?
On a busy computer every web game collapses today. The shooter kit measured a frame time of 100 ms at p95 with the main thread 100% busy.
What changed
OffscreenCanvas, then Wasm threads.How I checked it
pnpm prd:progress docs/PRDs/performance/PRD-752-…md→ exit 0,prd:0%, 4 phases, 14 boxes.pnpm check:docs→ 3562 links checked, pass.Risks
Still open
windowanddocumentonly throughplatform.ts. Native desktop gate passed. The starter HUD works over an async message port (?tnUiLoopback=1).References
docs/PRDs/performance/PRD-752-web-games-run-in-a-worker-so-the-page-cannot-stall-them.mdPRD progress: 50% — 2/4 phases (10/15 boxes), 0/0 acceptance (generated)
PRD:
docs/PRDs/performance/PRD-752-web-games-run-in-a-worker-so-the-page-cannot-stall-them.mdPhase 1: Loaded-host baseline — 2/2
sh scripts/xvfb.sh pnpm profile:wasm-page -- --url <url> --seconds 10 --warmup-ms 8000 --cpu-work --load 24 --json. Result:--loadandbusyPercentadded,scripts/__tests__/profile-wasm-page.spec.ts15/15 green, 12 runs recorded in Decisions## Decisions, with load, adapter name (not SwiftShader) and three runs per arm. The v5 KPI numbers are set from it. proof: the## Decisionsentry,adapter.infocaptured in the run. Result: baseline and KPI entries above, adapter "nvidia turing" in all 12 runsPhase 2: Host-capability layer — 3/3
packages/core/srcand fails on any read of a global a dedicated worker lacks (type check against thewebworkerlib, value positions only) outside the host-capability layerpackages/core/src/platform.ts. It is red on the current reads and green after the move. proof:pnpm exec vitest run scripts/__tests__/check-core-boundary.spec.ts. Result: red with 19 reads in 5 files, green 32/32 after the move tohostWindow/hostDocument/hostResizeObserver;pnpm exec tsx scripts/check-core-boundary.tspasses; 11 related core and boundary spec files 248/248; core typecheck exit 0import()in core. proof:pnpm exec vitest run scripts/__tests__/check-core-boundary.spec.ts;pnpm native:verify:desktop. Result: 32/32;native:verify:desktopexit 0 ("desktop core gate passed: 300 frames, 1280x720", UI frame gate passed) afterpnpm native:buildin this worktreepackages/core/__tests__/ui-bridge-inbound.spec.ts; a pause round-trip scenario (clickbutton[aria-pressed],hudassertspause->resume) on a scaffolded starter at/and at/?tnUiLoopback=1; the starter's ownpnpm test(test:templatesskips the starter: it runs in the golden-path lane). Result: spec 6/6; round trip exit 0 on both URLs; a Playwright probe reads__tnUiLoopbackfalse at/and true with the flag; kitmovement,survives,production-readinesspass.playfailsperformance.maxFrameMsP95on GPU time on this host, and the scaffold from before this change fails it too (before 2/2 runs at GPU p95 20.3/17.3 ms, after 1/2 at 18.1 ms; CPU about 7.5 ms in both)Phase 3: Worker mode and v5 KPI — 5/6
OffscreenCanvas; input, pointer lock and audio forward from the main thread, and the HUD takes one state snapshot per frame and sends intents back. proof:pnpm test:templateswith the flag, starter scenario green. Result: the flag became the default page (owner, Decisions); a scaffolded starter at/(worker),--browser-recipe webgpu --headed, RTX 2080 ("turing"), the worktree's runner at 7fe753b:movement,survives,play,production-readinessall exit 0;playreportsgameThread: "worker"pnpm test:templateswith the flag;node packages/playtest/dist/runner/cli.js doctor --url <url> --text. Result: the same runs capture non-blank frames withdata-engineprovenance from the page canvas (forwarded attribute) and pass their visual and tone assertions; 9 of the 10 starter and shooter scenarios exit 0 on/profile:wasm-pageworker URL against the main-thread--controlURL, three runs per arm. Result:--url <kit>/ --control <kit>/main.html --cpu-work --vsync --load 0, RTX 2080, hostload17 to 24, three pairs per kit. Shooter: busy 4.5 / 4.5 / 4.5% against 32.0 / 34.8 / 34.5%; presented p50 42.8 / 45.7 / 34.2 ms against 85.7 / 71.6 / 78.5 ms; p95 69 / 57 / 63 ms against 146 / 130 / 154 ms. Starter: busy 1.4 / 1.3 / 1.5% against 24.8 / 23.0 / 23.7%; presented p50 33.4 / 33.8 / 33.3 ms against 33.6 / 33.4 / 44.7 ms. At--load 24(load138 to 71, unmatched arms) the shooter's worker busy stayed at 0.5 to 1.4% against 44 to 47%, and p50 was lower in 2 of 3 pairs: frame time there is load, per the AFK rule--browser-recipe webgpu,adapter.infochecked. proof: the verdict in this box and the images on the PR. Result: OVERALL NEUTRAL (fresh Opus judge, 6 sheets: starterafterandtone-3, shooterafter, runs 1 and 2 per arm,/vs/main.html,--headed, all 8 runsarchitecture: "turing"). Exposure, tone, shadows, AA, resolution and HUD match; the only differences are gameplay state (starter position 57 vs 59, 60 vs 61). Images: branchprd-752-screenshotsat dcf2360pnpm test:templateswith the flag on the shooter scenariotn-native-engine-raycast-testandtn-native-engine-animation-skeleton-test raycast_posed_skininpackages/runtime-native/build/tn-engine-core(-DTN_ENGINE_CORE_ONLY=ON); the shooter'sTN_FRAME_BUDGETin Chrome on the RTX 2080. Result: 2,048 oracle rays 0 differ; 1,000 rays on a 20k-triangle mesh 8,471 ms -> 6.0 ms; a focused ray tests 4 of 20,000 triangles; posed skin hits 1/1 on both paths. Shooter on/(worker), 8 windows of 5 s: update p95 about 90 ms -> 5 to 10 ms, presented p95 100 ms -> 16.8 ms, 28 to 37 fps -> 56 to 60 fpsPhase 4: Wasm threads and gate — 0/4
TN_WEB_THREADSbuild costs at most 2% on one thread against the non-threaded build. proof:profile:wasm-pageA/B, three runs per armthreads: offand runs unchanged; with isolation (COOP/COEP) it reports the pool size. proof:native_engine_wasm_browser_backendfor both isolation statesprofile:wasm-pageA/B on the shooter, threaded against not